Skip to content

feat: third-party eval metrics adapter (DeepEval + Autoevals) with strands-evals mappers - #568

Merged
jariy17 merged 30 commits into
aws:mainfrom
stone-coding:feature/third-party-eval-adapter
Jul 29, 2026
Merged

feat: third-party eval metrics adapter (DeepEval + Autoevals) with strands-evals mappers#568
jariy17 merged 30 commits into
aws:mainfrom
stone-coding:feature/third-party-eval-adapter

Conversation

@stone-coding

@stone-coding stone-coding commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Add DeepEvalAdapter and AutoevalsAdapter that integrate third-party evaluation metrics with
AgentCore's code-based evaluator framework.

  • Span mapping via strands-evals: Uses detect_otel_mapper() from strands-agents-evals for
    auto-detection of Strands, OpenInference LangChain, and OpenTelemetry LangChain span formats.
    Bridge function converts SessionSpanMapResult for adapter consumption.
  • custom_mapper parameter lets customers bypass built-in mappers for unsupported frameworks:
    • DeepEval: custom_mapper: Callable[[EvaluatorInput], LLMTestCase]
    • Autoevals: custom_mapper: Callable[[EvaluatorInput], Dict[str, Any]]
  • tools_called + expected_tools extraction from execute_tool spans — enables
    ToolCorrectnessMetric and ArgumentCorrectnessMetric
  • assertions from reference_inputs mapped to LLMTestCase.context → should now say assertions from reference_inputs mapped to LLMTestCase.context (None when no assertions)
  • reference_inputs full pipeline: expectedResponse → expected_output,
    expectedTrajectory → expected_tools, assertions → context
  • Never raises unhandled exceptions — all error paths return valid EvaluatorOutput with
    structured errorCode/errorMessage
  • Pinned dependency: strands-agents-evals>=1.0.0,<2.0.0

Test plan

  • 38 unit tests passing (pytest tests/.../third_party/ -v)
  • 21 E2E tests passing across:
    • DeepEval: 12 metrics (RAG, Agentic, Custom/GEval, Contextual, Non-LLM, Safety + OpenInference +
      OpenTelemetry)
    • Autoevals: 6 metrics (LLM-Judge, LLM-Scorer, Deterministic + OpenInference + OpenTelemetry)
    • Custom mapper: 3 tests (Google ADK, OpenAI Agents, Autoevals path)
  • E2E validated against live AgentCore service (evaluate() API → Lambda → metric → score)

Introduces a new integrations/deepeval/ module that adapts AgentCore
Lambda evaluation events into DeepEval LLMTestCase objects, runs any
BaseMetric, and returns structured score/label/explanation responses.
- Rename span_parsers → span_mappers, simplify to Strands-only
- Rename field_mapper → customer_mapper across all adapters
- customer_mapper now returns native types directly:
  - DeepEval: EvaluatorInput → LLMTestCase
  - Autoevals: EvaluatorInput → Dict[str, Any] (eval kwargs)
- Refactor BaseAdapter: remove intermediate execute(), add _run() pattern
- 83 unit tests passing
Comment on lines +27 to +31
customer_mapper=lambda ev: {
"input": ev.session_spans[0]["attributes"]["question"],
"output": ev.session_spans[0]["attributes"]["answer"],
"expected": "the expected answer",
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to accept aws lambda?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced lambda examples with named functions in docstrings

def __init__(
self,
scorer: Any,
customer_mapper: Optional[Callable[[EvaluatorInput], Dict[str, Any]]] = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. We have not updated this type Dict[str, Any]?
  2. Regarding naming, "Custom mapper" would be a better choice for describing an arbitrary mapper provided by a customer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1.The type Dict[str, Any] is correct — Autoevals has no typed
input class like DeepEval's LLMTestCase. Scorers take plain
kwargs (input, output, expected) and the dict gets unpacked
as metric.eval(**kwargs). Different scorers need different
keys, so a generic dict is the right contract.

self,
scorer: Any,
customer_mapper: Optional[Callable[[EvaluatorInput], Dict[str, Any]]] = None,
threshold: float = 0.5,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

label: Optional[str] = None label in EvaluatorOutput is optional.
We don't need to have a default value to generate a label when a metric doesn't have a label and the user doesn't provide any threshold. Can we set default as None?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed threshold default to None. When no threshold is set,
no Pass/Fail judgment is made. Note: EvaluatorOutput's
validator currently requires a label for success responses —
I'm returning the score as the label string when threshold is
None. Let me know if you'd rather relax the validator to
allow label=None.

mapping when provided. Expected keys: input, output, expected (optional).
threshold: Score threshold for Pass/Fail determination. Defaults to 0.5.
"""
self.scorer = scorer

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this naming is not aligned with DeepEval adaptor. Looks like you haven't updated AutoevalsAdapter.
If you haven't completed end to end tests for AutoevalsAdapter, you should not include it in your PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working on E2E test plan now. Considering how to strcuture parameterized Lambda handlers and how to obtains span from strands, openinference, opentelemetry, and customer mappers.

customer_mapper=lambda ev: LLMTestCase(
input=ev.session_spans[0]["attributes"]["user_query"],
actual_output=ev.session_spans[0]["attributes"]["response"],
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using lambda? same as above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

def __init__(
self,
metric: BaseMetric,
customer_mapper: Optional[Callable[[EvaluatorInput], LLMTestCase]] = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming? same as above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

The AgentCore evaluation service sends spans in a normalized format
where input/output data is in events[] (gen_ai.user.message,
gen_ai.choice) instead of body with input/output. Neither
CloudWatchSessionMapper nor StrandsInMemorySessionMapper handles this.

Added _extract_from_service_format() as a fallback that parses gen_ai
semantic convention events directly when the primary mapper can't find
AgentInvocationSpans. Also falls back to CloudWatchSessionMapper when
StrandsInMemorySessionMapper is selected but spans are dicts (not
ReadableSpan objects).

Tested end-to-end: agentcore invoke → run eval → value=1, label=Pass.
)

if reference_inputs:
ref = reference_inputs[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attributes:
context: Span context for the entry, e.g. {"spanContext": {"sessionId", "traceId"}}.

Why do we use the first reference_input? If all the reference_inputs have an empty context or an empty spanContext, using the first one as the default makes sense. But what if the sessionId/traceId has been explicitly specified?
reference for SpanContext : https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_SpanContext.html

    "evaluationReferenceInputs": [{
            "context": {
                "spanContext": {
                    "sessionId": "a9f5f0c7-3c94-43cc-933d-7e191caf9d8d"
                }
            },
            "assertions": [{
                    "text": "search_flights tool call args are origin=SEA and destination=NYC"
                },
                {
                    "text": "search_hotels is called with city=NYC"
                }
            ],
            "expectedTrajectory": {
                "toolNames": ["search_flights", "book_flight", "search_hotels", "book_hotel"]
            }
        }, {
            "context": {
                "spanContext": {
                    "traceId": "69b0693f0ed8ff777d364fe4265fe2a4",
                    "sessionId": "a9f5f0c7-3c94-43cc-933d-7e191caf9d8d"
                }
            },
            "expectedResponse": {
                "text": "Booked flight DL420"
            }
        },
        {
            "context": {
                "spanContext": {
                    "traceId": "69b0696718e27bea18ac5a74148a81d8",
                    "sessionId": "a9f5f0c7-3c94-43cc-933d-7e191caf9d8d"
                }
            },
            "expectedResponse": {
                "text": "Booked the plaza hotel for $1350"
            }
        }
    ],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — now matches reference_input to the target trace by spanContext.traceId. If a reference input has a matching traceId, it's used. If no traceId is specified (empty context), it's treated as the default. Falls back to first entry if nothing matches.

Populates chatbot_role from system_prompt (defaults to 'A helpful AI
assistant') and expected_outcome from reference_inputs. Enables
RoleAdherenceMetric, TurnContextualPrecisionMetric, and
TurnContextualRecallMetric to work without custom_mapper.
Comment thread pyproject.toml
]
deepeval = [
"deepeval>=2.0.0",
"strands-agents-evals>=1.0.3,<2.0.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please regenerate uv.lock after this dependency change. It still resolves strands-agents-evals==0.1.0, which does not provide detect_otel_mapper. Frozen installs will fail at import time.

result = _session_to_span_map_result(session)

if reference_inputs:
ref = reference_inputs[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you use reference_inputs[0], you are only reading one scoped reference. A TRACE evaluation can contain both a session-level reference and a matching trace-level reference, so this can drop either expectedResponse or the session-level assertions and trajectory. Please inspect and combine the relevant entries instead of selecting one by position.

"""Integration tests for DeepEvalAdapter with real DeepEval metrics."""

@pytest.fixture(autouse=True)
def check_deepeval(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove this skip. Otherwise, CI can pass without making it clear that the test never ran.

if evaluator_input.evaluation_level == "TRACE" and evaluator_input.target_trace_id:
spans = [s for s in spans if s.get("traceId") == evaluator_input.target_trace_id]
elif evaluator_input.evaluation_level == "TOOL_CALL" and evaluator_input.target_span_id:
spans = [s for s in spans if s.get("spanId") == evaluator_input.target_span_id]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtering a TOOL_CALL evaluation to only the target span drops the enclosing AgentInvocationSpan. DeepEval's Tool Correctness documentation lists input, actual_output, tools_called, and expected_tools as required arguments. The agent span provides input and actual_output, while the targeted tool span provides the data for tools_called.

For example, suppose a trace contains an invoke_agent span with the input "What's the weather?" and output "72 F", plus an execute_tool span for get_weather. When target_span_id points to the tool span, this filter removes the agent span. map_spans() then raises No AgentInvocationSpan found in session, and the adapter returns FIELD_EXTRACTION_ERROR instead of running ToolCorrectnessMetric.

@model_validator(mode="after")
def _require_label_or_error_code(self) -> "EvaluatorOutput":
if not self.errorCode and self.label is None:
if not self.errorCode and self.label is None and self.value is None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successful AgentCore code-based evaluator responses cannot omit label. This change permits EvaluatorOutput(value=0.75, label=None), which is the default path in AutoEvalsAdapter when no threshold is provided.

I verified this against the live service. The Lambda completed successfully and returned:

{"value": 0.75, "explanation": "test"}

AgentCore rejected it with:

InvalidLambdaResponse: LambdaEvaluationSuccessResponse.label: Field required

The same Lambda succeeded when it included "label": "Pass". Please restore the existing label validation and make AutoEvalsAdapter always produce a label, such as by requiring a threshold or restoring the 0.5 default. The existing test_label_required_without_error_code also currently fails because this invalid response is accepted by the SDK model.

@@ -0,0 +1,5 @@
"""AutoEvals adapter for AgentCore code-based evaluators."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch does not pass the repository's required lint and formatting checks. Using the exact Ruff 0.12.0 version pinned by CI, ruff check . reports 10 errors (F401, G201, E501, and F841), and ruff format --check . reports four files that require formatting. The CI workflow runs pre-commit run --all-files, so the lint job will fail as submitted. Please run pre-commit run --all-files, address the remaining errors, and rerun it until the branch is clean.

When multiple reference_inputs are provided with different spanContext
traceIds, use the one matching the current target_trace_id instead of
blindly using the first entry. Falls back to first if no match found
or if reference_input has no traceId specified.
1. Regenerate uv.lock — strands-agents-evals now resolves to 1.0.3
2. Combine all relevant reference_inputs (session + trace level) instead
   of selecting first by position. Matches by spanContext.traceId.
3. Remove importorskip in integration tests — CI should not silently pass
4. TOOL_CALL filter includes parent agent span (provides input/actual_output
   needed by ToolCorrectnessMetric)
5. Restore label validation — AutoEvalsAdapter always produces a label
   (defaults to threshold=0.5 when not provided). AgentCore service
   requires label in successful responses.
jariy17
jariy17 previously approved these changes Jul 28, 2026
The AgentCore service propagates explanation but drops errorCode/errorMessage
in evaluation results. By duplicating the error message into explanation,
customers can see what went wrong in the eval results log group.
Error responses should only contain errorCode and errorMessage,
without label or explanation. The service treats responses with
label as success responses and ignores error fields.

Ref: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/code-based-evaluators.html
Validates all error paths return only errorCode + errorMessage (no label,
no explanation) per the service contract. Covers FIELD_EXTRACTION_ERROR,
MISSING_REQUIRED_FIELD, and METRIC_ERROR across both DeepEval and Autoevals
adapters.
jariy17
jariy17 previously approved these changes Jul 29, 2026
Keep our deepeval/autoevals extras and strands-agents-evals>=1.0.3.
Accept upstream's langchain deps, a2a-sdk v1, and [tool.uv] conflicts.
Regenerated uv.lock.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl PR size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants